import fs from 'fs';
import readline from 'readline';

import { Namespace } from 'argparse';

import NetRegexes from '../../resources/netregexes';
import PetData from '../../resources/pet_names';
import SFuncs from '../../resources/stringhandlers';
import { NetMatches } from '../../types/net_matches';

import { LogUtilArgParse, TimelineArgs } from './arg_parser';
import { printCollectedFights, printCollectedZones } from './encounter_printer';
import { EncounterCollector, FightEncInfo, TLFuncs } from './encounter_tools';
import FFLogs from './fflogs';

// TODO: Repeated abilities that need to be auto-commented may not get the comment marker
// if there's an intervening entry between the repeated entries.
// Figure out a more robust way to auto-comment lines that should be visible but unsynced.

// TODO: Add support for assigning sync windows to specific abilities,
// with or without phase conditions.

// TODO: Add support for auto-generating loops.

// TODO: Add support for compiling log lines during the collector pre-pass.

type TimelineEntry = {
  time: string;
  combatant?: string;
  abilityId?: string;
  abilityName?: string;
  targetable?: boolean;
  lineType: string;
  duration?: number;
  window?: { beforeWindow: number; afterWindow: number };
  zoneSeal?: { seal: string; code: '0839' };
  lineComment?: string;
};

type EncounterAbilityList = { [string: string]: string };

class ExtendedArgsRequired extends Namespace implements TimelineArgs {
  'file': string | null;
  'force': boolean | null;
  'search_fights': number | null;
  'search_zones': number | null;
  'fight_regex': string | null;
  'zone_regex': string | null;
  'output_file': string | null;
  'start': string | null;
  'end': string | null;
  'ignore_id': string[] | null;
  'ignore_ability': string[] | null;
  'ignore_combatant': string[] | null;
  'only_combatant': string[] | null;
  'phase': string[] | null;
  'include_targetable': string[] | null;
  'report_id': string | null;
  'report_fight': number | null;
  'key': string | null;
  'list_abilities': string | null;
}

type ExtendedArgs = Partial<ExtendedArgsRequired>;

// Some NPCs can be picked up by our entry processor.
// We list them out explicitly here so we can ignore them at will.
const ignoredCombatants = PetData['en'].concat([
  '',
  'Alisaie',
  'Alisaie\'s Avatar',
  'Alphinaud',
  'Alphinaud\'s Avatar',
  'Arenvald',
  'Carbuncle',
  'Carvallain',
  'Crystal Exarch',
  'Doman Liberator',
  'Doman Shaman',
  'Earthly Star',
  'Emerald Carbuncle',
  'Emerald Garuda',
  'Estinien',
  'Estinien\'s Avatar',
  'G\'raha Tia',
  'G\'raha Tia\'s Avatar',
  'Gosetsu',
  'Hien',
  'Liturgic Bell',
  'Lyse',
  'Mikoto',
  'Minfilia',
  'Mol Youth',
  'Moonstone Carbuncle',
  'Obsidian Carbuncle',
  'Raubahn',
  'Resistance Fighter',
  'Resistance Pikedancer',
  'Ruby Carbuncle',
  'Ruby Ifrit',
  'Ryne',
  'Thancred',
  'Thancred\'s Avatar',
  'Topaz Carbuncle',
  'Topaz Titan',
  'Urianger',
  'Urianger\'s Avatar',
  'Varshahn',
  'Y\'shtola',
  'Y\'shtola\'s Avatar',
  'Yugiri',
  'Zero',
]);

const timelineParse = new LogUtilArgParse();

timelineParse.parser.addArgument(['--output_file', '-of'], {
  nargs: '?',
  type: 'string',
  help: 'Optional location to write timeline to file.',
});

timelineParse.parser.addArgument(['--ignore_id', '-ii'], {
  nargs: '+',
  help: 'Ability IDs to ignore, e.g. 27EF',
});

timelineParse.parser.addArgument(['--ignore_ability', '-ia'], {
  nargs: '+',
  help: 'Ability names to ignore, e.g. Attack',
});

timelineParse.parser.addArgument(['--ignore_combatant', '-ic'], {
  nargs: '+',
  help: 'Combatant names to ignore, e.g. "Aratama Soul"',
});

timelineParse.parser.addArgument(['--only_combatant', '-oc'], {
  nargs: '+',
  help: 'Only the listed combatants will generate timeline data, e.g. "Aratama Soul"',
});

timelineParse.parser.addArgument(['--phase', '-p'], {
  nargs: '+',
  help: 'Abilities that indicate a new phase, and the time to jump to, e.g. 28EC:1000',
});

timelineParse.parser.addArgument(['--include_targetable', '-it'], {
  nargs: '+',
  help: 'Set this flag to include "34" log lines when making the timeline',
});

timelineParse.parser.addArgument(['--list_abilities', '-la'], {
  nargs: '?',
  help: 'Set this flag to include a complete/unfiltered ability list after the timeline',
});

timelineParse.parser.addArgument(
  ['-s', '--start'],
  {
    type: 'string',
    help: 'Timestamp of the start, e.g. \'12:34:56.789',
  },
);
timelineParse.parser.addArgument(
  ['-e', '--end'],
  { type: 'string', help: 'Timestamp of the end, e.g. \'12:34:56.789' },
);

const printHelpAndExit = (errString: string): void => {
  console.error(errString);
  timelineParse.parser.printHelp();
  process.exit(-1);
};

const validateArgs = (args: ExtendedArgs): void => {
  const hasFile = typeof args.file === 'string' && args?.file !== '';
  const hasReport = typeof args.report_id === 'string' && args?.report_id !== '';

  if (hasFile && hasReport || !hasFile && !hasReport)
    printHelpAndExit('Error: Must specify exactly one of -f or -r\n');

  if (args.fight_regex === '-1')
    printHelpAndExit('Error: Timeline generation does not currently support -fr\n');
  if (args.zone_regex === '-1')
    printHelpAndExit('Error: Timeline generation does not currently support -zr\n');

  if (hasFile) {
    if (!args.file?.includes('.log'))
      printHelpAndExit('Error: Must specify an FFXIV ACT log file, as log.log\n');
  } else if (hasReport) {
    if (typeof args.report_id !== 'string')
      printHelpAndExit('Error: Must specify a report ID.');
    if (typeof args.report_fight !== 'number' || args.report_fight < 0)
      printHelpAndExit('Error: Must specify a report fight index of 0 or greater');
  }

  // The remaining part of this function is to check for selecting part of the fight.
  if (typeof args.start === 'string' || typeof args.end === 'string') {
    if (typeof args.start !== 'string' || typeof args.end !== 'string')
      printHelpAndExit('Error: must specify both start and end, or use -lf, -lz, or -rf');
    return;
  }

  let numExclusiveArgs = 0;
  const exclusiveArgs = ['search_fights', 'search_zones', 'report_fight'] as const;
  for (const opt of exclusiveArgs) {
    if (args[opt] !== null && args[opt] !== undefined)
      numExclusiveArgs++;
  }

  if (numExclusiveArgs !== 1)
    printHelpAndExit('Error: Must specify exactly one of -lf, -lz, or -rf\n');
};

const makeCollectorFromPrepass = async (fileName: string, store: boolean) => {
  const collector = new EncounterCollector();
  const lineReader = readline.createInterface({
    input: fs.createReadStream(fileName),
  });
  for await (const line of lineReader) {
    // TODO: this could be more efficient if it stopped when it found the requested encounter.
    collector.process(line, store);
  }
  return collector;
};

const parseNameToggleToEntry = (matches: NetMatches['NameToggle']): TimelineEntry => {
  const targetable = matches.toggle === '01';
  const entry: TimelineEntry = {
    time: matches.timestamp,
    combatant: matches.name,
    lineType: 'nameToggle',
    targetable: targetable,
  };
  return entry;
};

const parseAbilityToEntry = (matches: NetMatches['Ability']): TimelineEntry => {
  let abilityName = matches.ability;
  if (abilityName.toLowerCase().includes('unknown_'))
    abilityName = '--sync--';
  const entry: TimelineEntry = {
    time: matches.timestamp,
    combatant: matches.source,
    abilityId: matches.id,
    abilityName: abilityName,
    lineType: 'ability',
  };
  return entry;
};

const parseReport = async (
  reportId: string,
  fightIndex: number,
  apiKey: string,
): Promise<{ 'entries': TimelineEntry[]; 'abilityTimes': { [abilityId: string]: number[] } }> => {
  const rawEntries = await FFLogs.parseReport(reportId, fightIndex, apiKey);

  const entries: TimelineEntry[] = [];
  const abilityTimeMap: { [abilityId: string]: number[] } = {};

  for (const event of rawEntries) {
    // FFLogs mixes 14 StartsUsing lines in with 15/16 Ability lines.
    if (event.type !== 'cast')
      continue;

    entries.push({
      time: new Date(event.timestamp).toISOString(),
      combatant: event.combatant,
      abilityId: event.abilityId,
      abilityName: event.abilityName,
      lineType: 'ability',
    });

    // Store off exact times for each ability's usages for later sync commenting
    const abilityId = event.abilityId;
    const abilityTimeStamp = event.timestamp;
    abilityTimeMap[abilityId] ??= [];
    if (!abilityTimeMap[abilityId]?.includes(event.timestamp))
      abilityTimeMap[abilityId]?.push(abilityTimeStamp);
  }
  return { entries: entries, abilityTimes: abilityTimeMap };
};

const extractRawLinesFromLog = async (
  fileName: string,
  start: string | Date,
  end: string | Date,
): Promise<string[]> => {
  const lines: string[] = [];
  const file = readline.createInterface({
    input: fs.createReadStream(fileName),
  });
  start = typeof start === 'string' ? start : TLFuncs.timeFromDate(start);
  end = typeof end === 'string' ? end : TLFuncs.timeFromDate(end);
  let started = false;
  for await (const line of file) {
    // This will fail on lines with 3-digit identifiers,
    // but that's okay because those will never be start lines.
    const lineTimeStamp = line.slice(14, 26);
    if (start === lineTimeStamp && !started)
      started = start === lineTimeStamp;
    if (started)
      lines.push(line);
    if (lineTimeStamp === end) {
      file.close();
      return lines;
    }
  }
  file.close();
  return lines;
};

const extractTLEntriesFromLog = (
  lines: string[],
  targetArray: string[] | null,
): { 'entries': TimelineEntry[]; 'abilityTimes': { [abilityId: string]: number[] } } => {
  const entries: TimelineEntry[] = [];
  const abilityTimeMap: { [abilityId: string]: number[] } = {};
  for (const line of lines) {
    const targetable = NetRegexes.nameToggle().exec(line)?.groups;
    const ability = NetRegexes.ability().exec(line)?.groups;
    // Cull non-relevant lines immediately.

    if (!ability && !targetable)
      continue;

    // Make nameplate toggle lines if and only if the user has specified them.
    if (targetable) {
      if (targetArray !== null && targetArray.includes(targetable.name)) {
        const targetEntry = parseNameToggleToEntry(targetable);
        entries.push(targetEntry);
      }
      continue;
    }

    // At this point, only ability lines are left.
    if (ability) {
      // Cull non-enemy lines
      // TODO: Handle this using the raid emulator's line parsing functionality.
      if (!ability.sourceId.startsWith('4'))
        continue;
      const abilityEntry = parseAbilityToEntry(ability);
      entries.push(abilityEntry);

      // Store off exact times for each ability's usages for later sync commenting
      abilityTimeMap[ability.id] ??= [];
      const timestamp = Date.parse(ability.timestamp);
      if (!abilityTimeMap[ability.id]?.includes(timestamp))
        abilityTimeMap[ability.id]?.push(timestamp);
      continue;
    }

    // There shouldn't be any way that we get here,
    // but if we do, something is drastically wrong.
    // Notify the user of the malformed line and continue.
    const errString = `Warning: Potentially malformed/corrupted log line found:\n${line}\n\n`;
    console.log(errString);
    continue;
  }

  if (entries.length === 0) {
    console.error('Fight not found');
    process.exit(-2);
  } else {
    return { entries: entries, abilityTimes: abilityTimeMap };
  }
};

const ignoreTimelineAbilityEntry = (entry: TimelineEntry, args: ExtendedArgs): boolean => {
  const abilityName = entry.abilityName;
  const abilityId = entry.abilityId;
  const combatant = entry.combatant;

  // Ignore auto-attacks named "attack"
  if (abilityName?.toLowerCase() === 'attack')
    return true;

  // Ignore abilities from NPC allies.
  if (combatant !== undefined && ignoredCombatants.includes(combatant))
    return true;

  // Ignore abilities by name.
  if (abilityName !== undefined && args.ignore_ability?.includes(abilityName))
    return true;

  // Ignore abilities by ID
  if (abilityId !== undefined && args.ignore_id?.includes(abilityId))
    return true;

  // Ignore combatants by name
  if (combatant !== undefined && args.ignore_combatant?.includes(combatant))
    return true;

  // If only-combatants was specified, ignore all combatants not in the list.
  if (combatant !== undefined && args.only_combatant && !args.only_combatant?.includes(combatant))
    return true;
  return false;
};

const findTimeDifferences = (lastTimeDiff: number): { diffSeconds: number; drift: number } => {
  if (lastTimeDiff === 0)
    return { diffSeconds: 0, drift: 0 };
  let diffSeconds = Math.floor(lastTimeDiff / 1000);
  const diffMilliSeconds = lastTimeDiff - diffSeconds * 1000;
  let drift = 0;

  // Find the difference in tenths of a second.
  const diffTenthSeconds = Math.floor(diffMilliSeconds / 100) / 10;

  // Adjust full-second difference.
  diffSeconds += diffTenthSeconds;

  // Round up a tenth of a second.
  if (diffMilliSeconds > 600) {
    diffSeconds += 0.1;
  } else if (diffMilliSeconds > 500) {
    // Round up, warning of exceptional drift.
    diffSeconds += 0.1;
    drift = diffMilliSeconds - 1000;
  } else if (diffMilliSeconds > 400) {
    // Round down, warning of exceptional drift
    drift = diffMilliSeconds;
  } else {
    // If <20ms then there's no need to adjust sec or drift
    true;
  }
  return { diffSeconds: diffSeconds, drift: drift };
};

const assembleTimelineStrings = (
  entries: TimelineEntry[],
  abilityTimes: { [abilityId: string]: number[] },
  start: Date,
  args: ExtendedArgs,
  fight?: FightEncInfo,
): string[] => {
  const assembled: string[] = [];
  let lastAbilityTime = start.getTime();
  let timelinePosition = 0;
  let lastEntry: TimelineEntry = { time: lastAbilityTime.toString(), lineType: 'None' };
  if (fight !== undefined && fight.sealName !== undefined) {
    const sealMessage = SFuncs.toProperCase(fight.sealName);
    if (fight.sealId !== undefined) {
      const sealComment = `# ${sealMessage} will be sealed off`;
      const netLogSeal =
        `0.0 "--sync--" SystemLogMessage { id: "7DC", param1: "${fight.sealId}" } window 0,1`;
      assembled.push(sealComment);
      assembled.push(netLogSeal);
    } else {
      const tlString =
        `0.0 "--sync--" GameLog { code: "0839", line: "${sealMessage} will be sealed off.*?" } window 0,1`;
      assembled.push(tlString);
    }
  } else {
    assembled.push('0.0 "--sync--" InCombat { inGameCombat: "1" } window 0,1');
  }

  // If the user entered phase information,
  // process it and store it off.
  const phases: { [name: string]: number } = {};
  for (const phase of args.phase ?? []) {
    const [ability, time] = phase.split(':');
    if (ability !== undefined && time !== undefined)
      phases[ability] = parseFloat(time);
  }

  const encounterAbilityList: EncounterAbilityList = {};
  for (const entry of entries) {
    if (entry.lineType === 'ability') {
      // In order to list out all abilities we see in the timeline header,
      // we store them off during the entry collection process.
      // Unfortunately, because of the interaction of old type structure and linter requirements,
      // we have to do a giant block to ensure no undefined values sneak in.
      const id = entry.abilityId;
      const name = entry.abilityName;
      if (id !== undefined && name !== undefined && encounterAbilityList[id] === undefined) {
        // We want all enemy abilities *except* from the specific NPCs in the curated ignore list.
        const combatant = entry.combatant;
        if (combatant !== undefined && !ignoredCombatants.includes(combatant))
          encounterAbilityList[id] = name;
      }

      // Ignore auto-attacks, NPC allies, and abilities based on user-entered flags.
      if (ignoreTimelineAbilityEntry(entry, args))
        continue;
    }

    // Ignore AoE spam
    if (lastEntry.time === entry.time) {
      if (
        entry.abilityId !== undefined &&
        lastEntry.abilityId !== undefined &&
        entry.abilityId === lastEntry.abilityId
      )
        continue;
    }

    // Ignore targetable lines if not specified
    if (entry.lineType === 'targetable' && !Array.isArray(args.include_targetable))
      continue;

    // Find out how long it's been since the last ability.
    const lineTime = Date.parse(entry.time);
    const lastTimeDiff = lineTime - lastAbilityTime;
    const timeInfo = findTimeDifferences(lastTimeDiff);

    // Set the time, adjusting to phases if necessary.
    const abilityId = entry.abilityId ?? 'Unknown';
    const phaseTime = abilityId ? phases[abilityId] : timelinePosition;
    if (
      !(entry.lineType === 'ability' && abilityId && Object.keys(phases).includes(abilityId))
    ) {
      timelinePosition += timeInfo.diffSeconds;
    } else if (abilityId && phaseTime && Object.keys(phases).includes(abilityId)) {
      timelinePosition = phaseTime;
      delete phases[abilityId];
    }

    // We're done manipulating time, so save where we are for the next loop.
    lastAbilityTime = lineTime;

    // If a given use of an ability is within 2.5 seconds of another use,
    // we want to comment it by default.
    const checkAbilityTime = (element: number) => Math.abs(lineTime - element) <= 2500;
    const lineAbilityTimeList = abilityTimes[abilityId];
    let commentSync = '';
    if (lineAbilityTimeList !== undefined && lineAbilityTimeList.length > 1) {
      const overlaps = lineAbilityTimeList.filter(checkAbilityTime).length > 1;
      commentSync = overlaps ? '#' : '';
    }

    if (entry.lineType !== 'nameToggle') {
      const ability = entry.abilityName ?? 'Unknown';
      const combatant = entry.combatant ?? 'Unknown';
      const newEntry = `${
        timelinePosition.toFixed(1)
      } "${ability}" ${commentSync}Ability { id: "${abilityId}", source: "${combatant}" }`;
      assembled.push(newEntry);
    } else {
      const targetable = entry.targetable ? '--targetable--' : '--untargetable--';
      const newEntry = `${timelinePosition.toFixed(1)} "${targetable}"`;
      assembled.push(newEntry);
    }
    lastEntry = entry;
  }
  const ignoreLines = assembleHeaderArgStrings(args);
  const definiteLines = ignoreLines.concat(assembled);
  // Generate a complete table of abilities if specified.
  // Otherwise just return the timeline and basic header info.
  if (args.list_abilities !== undefined)
    return definiteLines.concat(assembleAbilityTableStrings(args, encounterAbilityList));
  return definiteLines;
};

const assembleHeaderZoneInfoStrings = (fight: FightEncInfo): string[] => {
  const zoneName = fight.zoneName;
  const zoneId = fight.zoneId;
  const headerInfo = [];
  if (zoneName !== undefined) {
    const zoneNameLine = `### ${zoneName.toUpperCase()}`;
    headerInfo.push(zoneNameLine);
  }
  if (zoneId !== undefined) {
    const zoneIdLine = `# ZoneId: ${zoneId}`;
    headerInfo.push(zoneIdLine);
  }
  if (headerInfo.length > 0)
    headerInfo.push('');
  return headerInfo;
};

const assembleHeaderArgStrings = (
  args: ExtendedArgs,
): string[] => {
  const assembled = [];
  let padHeaderArgs = false;

  const sortedAbilityIgnore = args.ignore_id?.sort();
  if (sortedAbilityIgnore !== undefined) {
    // Compared to combatant names, abilities are always guaranteed to be single "words".
    const iiLine = `# -ii ${sortedAbilityIgnore.join(' ')}`;
    assembled.push(iiLine);
    padHeaderArgs = true;
  }

  const sortedCombatantIgnore = args.ignore_combatant?.sort();
  if (sortedCombatantIgnore !== undefined) {
    // A naive sortedCombatantIgnore.join(' ') would be incorrect
    // if there are combatants with names containing more than one word,
    // as for instance "Brute Justice".
    // Single-word combatant names will not be affected by being quote-wrapped.
    const joinedIgnore = sortedCombatantIgnore.map((x) => `"${x}"`).join(' ');
    assembled.push(`# -ic ${joinedIgnore}`);
    padHeaderArgs = true;
  }

  const knownTargetable = args.include_targetable;
  if (knownTargetable !== undefined && knownTargetable !== null) {
    // Assume that the user knows best as far as sorting these args.
    const joinedTargetable = knownTargetable.map((x) => `"${x}"`).join(' ');
    assembled.push(`# -it ${joinedTargetable}`);
    padHeaderArgs = true;
  }

  const phases = args.phase;
  if (phases !== undefined && phases !== null) {
    // It's a lot of extra work to sort the phase information for minimal gain.
    // Assume that the user knows best as far as sorting these args.
    const phaseLine = `# -p ${phases.join(' ')}`;
    assembled.push(phaseLine);
    padHeaderArgs = true;
  }

  // Here and in the ability ignore block, we pad string ends with newlines, not the starts.
  // We assume newline padding from the zone Id function, and if it's not present,
  // the ignore header should be on line 1 of the file.
  if (padHeaderArgs)
    assembled.push('');
  assembled.push('hideall "--Reset--"\nhideall "--sync--"\n');
  return assembled;
};

const assembleAbilityTableStrings = (
  args: ExtendedArgs,
  encounterAbilityList: EncounterAbilityList,
): string[] => {
  // Start with a guaranteed extra newline to space out the timeline from the table.
  const assembled: string[] = ['\n'];
  const sortedAbilityIgnore = args.ignore_id?.sort();
  if (sortedAbilityIgnore !== undefined) {
    // The ignore header is inside the conditional block to ensure it's not added spuriously.
    assembled.push('# IGNORED ABILITIES');
    for (const id of sortedAbilityIgnore) {
      const abilityName = encounterAbilityList[id];
      if (abilityName !== undefined) {
        const detailedIgnoreLine = `# ${id} ${abilityName}`;
        assembled.push(detailedIgnoreLine);
      }
    }
    assembled.push('');
  }

  // While the user may not always ignore abilities,
  // we are guaranteed to be listing all abilities if we arrive here.
  // Add the encounter ability header here outside the block,
  // since we can safely assume the encounter will have at least one ability.
  assembled.push('# ALL ENCOUNTER ABILITIES');
  for (const id of (Object.keys(encounterAbilityList).sort())) {
    const abilityName = encounterAbilityList[id];
    if (abilityName !== undefined) {
      const listedLine = `# ${id} ${abilityName}`;
      assembled.push(listedLine);
    }
  }
  return assembled;
};

const parseTimelineFromFile = async (
  args: ExtendedArgs,
  file: string,
  fight: FightEncInfo,
) => {
  const startTime = fight.startTime;
  const endTime = fight.endTime;
  // All encounters on a collector will guaranteed have a start/end time,
  // but Typescript doesn't know that.
  if (!(startTime && endTime)) {
    console.error('Missing start or end time at specified index.');
    process.exit(1);
  }
  // This logic can probably be split out once we re-enable support for raw start/end times.
  let lines: string[];

  if (fight.logLines !== undefined) {
    lines = fight.logLines;
  } else {
    lines = await extractRawLinesFromLog(
      file,
      TLFuncs.timeFromDate(startTime),
      TLFuncs.timeFromDate(endTime),
    );
  }
  const baseEntries = extractTLEntriesFromLog(
    lines,
    args?.include_targetable ?? null,
  );
  const assembled = assembleTimelineStrings(
    baseEntries.entries,
    baseEntries.abilityTimes,
    startTime,
    args,
    fight,
  );
  return assembled;
};

const printTimelineToConsole = (entryList: string[]): void => {
  if (entryList.length > 0)
    console.log(entryList.join('\n'));
};

const writeTimelineToFile = (entryList: string[], fileName: string, force: boolean): void => {
  const flags = force ? 'w' : 'wx';
  const writer = fs.createWriteStream(fileName, { flags: flags });
  writer.on('error', (err) => {
    console.error(err);
    process.exit(-1);
  });
  if (entryList.length > 0) {
    for (const entry of entryList) {
      writer.write(entry);
      writer.write('\n');
    }
  }
  writer.close();
};

const makeTimeline = async () => {
  const args: ExtendedArgs = new ExtendedArgsRequired({});
  timelineParse.parser.parseArgs(undefined, args);
  validateArgs(args);

  let assembled: string[] = [];
  if (
    typeof args.report_id === 'string' && typeof args.report_fight === 'number' &&
    typeof args.key === 'string'
  ) {
    const rawEntries = await parseReport(args.report_id, args.report_fight, args.key);
    // Account for the possibility of a malformed response that somehow
    // ends up with a defined encounter but produces bogus or no entries.
    if (rawEntries.entries.length === 0 || rawEntries.entries[0] === undefined) {
      console.error('No encounter found in the report at that fight index.');
      process.exit(-2);
    }
    const startTime = new Date(rawEntries.entries[0].time);
    assembled = assembleTimelineStrings(
      rawEntries.entries,
      rawEntries.abilityTimes,
      startTime,
      args,
    );
  }

  if (typeof args.file === 'string' && args.file.length > 0) {
    if (typeof args.start === 'string' && typeof args.end === 'string') {
      const fight: FightEncInfo = {
        startTime: new Date(args.start),
        endTime: new Date(args.end),
      };
      assembled = await parseTimelineFromFile(args, args.file, fight);
    }

    const store = typeof args.search_fights === 'number' && args.search_fights > 0;
    const collector = await makeCollectorFromPrepass(args.file, store);
    if (args['search_fights'] === -1) {
      printCollectedFights(collector);
      process.exit(0);
    }
    if (args.search_zones === -1) {
      printCollectedZones(collector);
      process.exit(0);
    }
    // All fights are 1-indexed on collectors,
    // so we subtract 1 from the user's 1-indexed selection.
    if (args.search_fights) {
      const fight = collector.fights[args.search_fights - 1];
      if (fight === undefined) {
        console.error('No fight found at specified index');
        process.exit(-2);
      }
      const fightHeader = assembleHeaderZoneInfoStrings(fight);
      assembled = fightHeader.concat(await parseTimelineFromFile(args, args.file, fight));
    }
  }
  if (typeof args.output_file === 'string') {
    const force = typeof args.force === 'boolean' && args.force;
    writeTimelineToFile(assembled, args.output_file, force);
  } else {
    printTimelineToConsole(assembled);
  }
  process.exit(0);
};

void makeTimeline();
